Skip to content

State WAL replacement#3701

Open
cody-littley wants to merge 23 commits into
mainfrom
cjl/flatkv-wal
Open

State WAL replacement#3701
cody-littley wants to merge 23 commits into
mainfrom
cjl/flatkv-wal

Conversation

@cody-littley

Copy link
Copy Markdown
Contributor

Describe your changes and provide context

This WAL is intended to replace all existing WALs in both SS and SC. Instead of having multiple WALs for multiple different services, we can maintain just a single WAL which is used universally across all parts of the storage stack.

This PR does not integrate the changes, it just creates a WAL utility. Integration work will happen in future PRs. We've got a pre-existing WAL, but that implementation has some serious issues. Namely, the WAL library we currently use has very inefficient garbage collection (it rewrites garbage collected files, as opposed to dropping files as a whole when data in file is all stale).

Testing performed to validate your change

unit tests

@github-actions

github-actions Bot commented Jul 6, 2026

Copy link
Copy Markdown

The latest Buf updates on your PR. Results from workflow Buf / buf (pull_request).

BuildFormatLintBreakingUpdated (UTC)
✅ passed✅ passed✅ passed✅ passedJul 10, 2026, 2:56 PM

@codecov

codecov Bot commented Jul 6, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 78.47866% with 232 lines in your changes missing coverage. Please review.
✅ Project coverage is 59.04%. Comparing base (c6ab0be) to head (d9a978b).
⚠️ Report is 11 commits behind head on main.

Files with missing lines Patch % Lines
sei-db/seiwal/seiwal_file.go 64.75% 50 Missing and 42 partials ⚠️
sei-db/seiwal/seiwal_impl.go 79.61% 43 Missing and 31 partials ⚠️
sei-db/seiwal/seiwal_serializing.go 81.38% 24 Missing and 11 partials ⚠️
sei-db/state_db/statewal/state_wal_impl.go 81.69% 7 Missing and 6 partials ⚠️
sei-db/seiwal/seiwal_iterator.go 92.38% 4 Missing and 4 partials ⚠️
...ei-db/state_db/statewal/state_wal_serialization.go 79.48% 4 Missing and 4 partials ⚠️
sei-db/seiwal/metrics.go 75.00% 1 Missing and 1 partial ⚠️
Additional details and impacted files

Impacted file tree graph

@@            Coverage Diff             @@
##             main    #3701      +/-   ##
==========================================
- Coverage   59.85%   59.04%   -0.81%     
==========================================
  Files        2278     2200      -78     
  Lines      189140   180398    -8742     
==========================================
- Hits       113202   106524    -6678     
+ Misses      65861    64500    -1361     
+ Partials    10077     9374     -703     
Flag Coverage Δ
sei-chain-pr 77.67% <77.67%> (?)
sei-db 70.41% <ø> (ø)
sei-db-state-db ?
sei-db-state-db-pr 84.21% <84.21%> (?)

Flags with carried forward coverage won't be shown. Click here to find out more.

Files with missing lines Coverage Δ
sei-db/seiwal/seiwal_config.go 100.00% <100.00%> (ø)
sei-db/state_db/statewal/state_wal_config.go 100.00% <100.00%> (ø)
sei-db/seiwal/metrics.go 75.00% <75.00%> (ø)
sei-db/seiwal/seiwal_iterator.go 92.38% <92.38%> (ø)
...ei-db/state_db/statewal/state_wal_serialization.go 79.48% <79.48%> (ø)
sei-db/state_db/statewal/state_wal_impl.go 81.69% <81.69%> (ø)
sei-db/seiwal/seiwal_serializing.go 81.38% <81.38%> (ø)
sei-db/seiwal/seiwal_impl.go 79.61% <79.61%> (ø)
sei-db/seiwal/seiwal_file.go 64.75% <64.75%> (ø)

... and 87 files with indirect coverage changes

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

@cody-littley cody-littley marked this pull request as ready for review July 6, 2026 17:35
@cody-littley cody-littley requested a review from blindchaser July 6, 2026 17:35
@cursor

cursor Bot commented Jul 6, 2026

Copy link
Copy Markdown

PR Summary

High Risk
New crash-durable persistence and recovery logic for state; bugs could cause data loss or silent truncation, though nothing calls it in production until follow-up integration PRs.

Overview
Adds a standalone WAL stack meant to eventually replace fragmented SS/SC WALs, with no integration in this PR.

seiwal is a generic, monotonic-index append-only log: CRC-framed records in rotatable sealed files ({seq}-{first}-{last}.wal), a single writer goroutine, async prune of whole sealed files, open-time validation and orphan/rollback crash recovery, snapshot iterators with read leases and prefetch, optional fsync on flush, and OpenTelemetry counters/gauges (per-instance wal label).

NewGenericWAL wraps the byte engine with a serializer goroutine and typed iterators. statewal maps block numbers to WAL indices, accumulates NamedChangeSet writes until SignalEndOfBlock, and encodes payloads with a versioned protobuf framing layer.

Extensive unit tests cover torn tails, corruption at open/iterate, pruning vs live iterators, IO failure “bricking,” and rollback/reconcile paths.

Reviewed by Cursor Bugbot for commit d9a978b. Bugbot is set up for automated code reviews on this repo. Configure here.

seidroid[bot]

This comment was marked as low quality.

Comment thread sei-db/state_db/statewal/state_wal_impl.go
Comment thread sei-db/state_db/statewal/state_wal_entry.go Outdated
seidroid[bot]

This comment was marked as low quality.

Comment thread sei-db/state_db/statewal/state_wal_impl.go Outdated
if err := w.enforceWriteOrdering(blockNumber); err != nil {
return fmt.Errorf("write rejected: %w", err)
}
if err := w.sendToSerializer(dataToBeSerialized{entry: NewEntry(blockNumber, cs)}); err != nil {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Write keeps caller-owned changesets and serializes them later. Because Write returns after enqueueing, callers can mutate or reuse cs before serializerLoop runs, causing the WAL to persist changed data or race with the caller

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This pattern is intentional for performance, but not well documented.

I updated all godocs to say that it is unsafe to modify slices passed into methods or slices received from methods.

seidroid[bot]

This comment was marked as low quality.

Comment thread sei-db/seiwal/metrics.go
Comment thread sei-db/seiwal/seiwal_impl.go
Comment thread sei-db/state_db/statewal/state_wal.go
seidroid[bot]

This comment was marked as low quality.

@cursor cursor Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cursor Bugbot has reviewed your changes using default effort and found 2 potential issues.

There are 3 total unresolved issues (including 1 from previous review).

Fix All in Cursor

❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.

Reviewed by Cursor Bugbot for commit d9a978b. Configure here.


if err := w.wal.Append(blockNumber, changeset); err != nil {
return fmt.Errorf("failed to append block %d: %w", blockNumber, err)
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

End block before append succeeds

High Severity

SignalEndOfBlock sets currentBlockEnded and clears buf before wal.Append succeeds. If scheduling fails, the block is treated as finalized while its changesets were never queued, so further writes to that block are rejected and a later write to the next block number is allowed—silently skipping the lost block.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit d9a978b. Configure here.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

the StateWAL now bricks itself on the first error, making this scenario no longer a problem.

Comment thread sei-db/seiwal/seiwal_impl.go
Comment thread sei-db/seiwal/seiwal.go
Comment on lines +9 to +11
// A WAL instance is not safe for concurrent use: its methods must not be called from multiple
// goroutines simultaneously. Callers that share a WAL across goroutines must serialize access
// themselves.

@yzang2019 yzang2019 Jul 10, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Shall we consider making it goroutine concurrency safe then? It would things much easier from user perspective. My concern is if we implement a non thread safe library, it might makes future integration error prune especially when someone who is not familiar with the library.

I could also imagine sometimes we have to pass WAL around to different objects and call functions in different goroutines, it might be hard to always serialize the function calls of the WAL for both reads and writes.

But if the current usage doesn't require any concurrency call, then I think I'm good for the first pass and we can add that later when we really have concurrency usage

Comment thread sei-db/seiwal/seiwal.go
// data, and every slice reachable through it, must not be modified after this call: the payload may be
// retained and serialized asynchronously, so mutating it races the WAL and can corrupt what is
// persisted. Callers that need to reuse or mutate the buffer must copy it first.
Append(index uint64, data T) error

@yzang2019 yzang2019 Jul 10, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What's the point of allowing Index to not be contiguous here?
Is there any scenario that we want to do:
append(1, xxx), append(3, xxx), append(4, xxx), append(10, xxx)

On the other hand, do we really need the caller of this API to pass in the index? If the write pattern is always append only, I wonder if we can just internally manage this index and auto increment it? The benefit is that the caller doesn't need to think about how to get and increment the index any more during writes. So the suggested API would look like this:

Append(data T) error

Comment thread sei-db/seiwal/seiwal.go
// async and lazy, and implementations are free to delay it arbitrarily long. Pruning removes whole
// sealed files only, so records may survive above the requested threshold until their containing file
// is fully below it.
Prune(lowestIndexToKeep uint64) error

@yzang2019 yzang2019 Jul 10, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This Prune (recommend to rename to PruneBefore) is equivalent to truncate from first to lowestIndexToKeep.
I think we need another API PruneAfter() that is equivalent to truncate from lastIndexToKeep to last.

@yzang2019 yzang2019 Jul 10, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

One use case I think think of PruneAfter useful is for mannual rollback

Comment thread sei-db/seiwal/seiwal.go
// start and the return of this call. Records appended before that instant are included; records
// appended after it are not. For records appended concurrently with this call, whether they are
// included is unspecified.
Iterator(startIndex uint64) (Iterator[T], error)

@yzang2019 yzang2019 Jul 10, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Personally I would recommend to replace this API or add another API for Replay(startIndex, endIdex) to match our existing usage pattern.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The iterator exists to support streaming reads concurrent with ongoing writes and pruning. That's why it needs all the machinery:

  • Sealing the mutable file — snapshot the write frontier so the iterator sees a consistent point-in-time view
  • Pinning (indexRefs) — prevent Prune() from deleting files the iterator hasn't read yet
  • Prefetch goroutine — read ahead so the consumer isn't blocked on I/O
  • Close lifecycle — unpin so pruning can resume

The core question is: does the WAL actually need streaming reads concurrent with writes and pruning? How a WAL is typically used in a blockchain node?

In practice, WAL replay happens in two scenarios:

  1. Startup recovery — replay all records to rebuild uncommitted state. This happens before new writes begin. No concurrency with appends or pruning.
  2. Periodic checkpointing — read records since last checkpoint, apply them, then prune. This could be concurrent with new appends, but pruning only removes records before the read range, not within it.

In neither case do you need a long-lived iterator that coexists with concurrent writes and pruning to the same range. You just need: "give me all records from index X to the end."

What a replay function would look like? The simplest version:
func (w *walImpl) Replay(startIndex uint64, fn func(index uint64, data []byte) error) error
Implementation-wise, you'd send a replay request through the writer channel (so it's ordered relative to appends). The writer goroutine would:

  1. Flush (not seal) the mutable file
  2. Collect the list of sealed files + the mutable file path and its current size
  3. Return that snapshot to the caller

Then the caller reads through those files sequentially, calling fn for each record. No background goroutine, no pinning, no lifecycle management.
The pruning concern vanishes for two reasons. On the pruning-during-replay race — you have two clean options:

Option A (simplest): Open all relevant file handles upfront before returning from the writer goroutine. On Linux/macOS, an open file handle survives unlink, so even if Prune() deletes the file from the directory, the handle keeps the data accessible. No pinning needed at all.

Option B: Just document that the caller must not prune indices it's replaying. Since the caller controls both replay and prune, this is trivially enforceable — you prune after replay completes, not during.

What you'd delete? With a replay function, you can remove:

  • walIterator struct and the entire seiwal_iterator.go file
  • The prefetch goroutine, stop channel, readerExited channel
  • indexRefs map and the entire pinning mechanism (pinLowestReadableIndex, unpinIndex, lowestReservation)
  • sealForIterator — no more forced rotation on reads
  • The iteratorRequest message type in the writer channel
  • The it.done data race issue
  • The IteratorPrefetchSize config field

That's a substantial reduction in surface area. The core WAL (append, flush, seal, prune, recover) stays the same.

What you'd lose
The only thing you give up is streaming reads with bounded memory during concurrent operation. Specifically:

If you use Option A (open handles upfront), you hold file descriptors for the duration of replay. With many sealed files this could be a lot of FDs, but in practice the count is small.

If the replay callback is slow, new appends continue normally (the mutable file keeps growing), but the replayed snapshot doesn't include them. This is fine — replay is point-in-time by definition.
You lose the ability to do lazy/partial reads (stop iteration early). But with a callback, fn can return a sentinel error to stop early — same effect, simpler mechanism.

Comment on lines +770 to +771
for _, info := range sealedFiles.Iterator() {
contents, err := readWalFile(filepath.Join(directory, info.name))

@yzang2019 yzang2019 Jul 10, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Startup validates every sealed file by reading it entirely into memory which could be very slow and could evict page cache which could impact performance.

readWalFile does this:
data, err := os.ReadFile(path)

It is not uncommon that the WAL files could be accumulated into GB or even TB large, at that scale, starting up could take minutes/hours to finish the scanning

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The sealed files already have per-record CRCs baked into the format. So any corruption — bit-rot, truncation, garbled bytes — will be caught when that record is actually read during replay. The startup validation is doing the exact same CRC checks again, ahead of time, for records you may never even read (if they get pruned before the next replay).

And reading GB of WAL files at startup has a second nasty effect: it floods the OS page cache with cold WAL data, evicting hot pages from the actual database (memiavl, flatKV, pebble, etc.). The first minutes of operation after startup become slow as the real working set faults back in from disk.

Recommendation: three tiers of startup validation
Tier 1 — Metadata-only (default, microseconds)
At startup, only stat() each sealed file. Verify structural invariants without reading any file contents. This catches: missing files, zero-length/truncated files, naming inconsistencies, index gaps. Cost is one stat syscall per file — microseconds even with thousands of files.

Tier 2 — Bookend validation (optional, milliseconds)
Read only the header + first record + last record of each file. This catches filename-content mismatches without reading the interior.
Cost: two small reads per file (first few bytes + last ~1MB scanned for the final record). For 1000 files this is still sub-second.

Tier 3 — Full validation (optional, background)
Keep the current full-read CRC validation but run it after startup, in a background goroutine, and use fadvise to avoid polluting the page cache.


// NewWALWithRollback opens a byte-oriented WAL and deletes all records with an index greater than
// rollbackIndex before returning, so the WAL contains no record with an index greater than rollbackIndex.
func NewWALWithRollback(config *Config, rollbackIndex uint64) (WAL[[]byte], error) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not a big fan of adding rolbackIndex to the constructor, I mean it's fine to keep, but we should also support ondemand rollbacks, not just auto rollback during initialization.

index: index,
serialize: func() ([]byte, error) { return s.serialize(data) },
}
if err := s.submit(req); err != nil {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The inner walImpl.Append enforces strict index ordering via appendMu, but serializingWAL.Append does not. Since serialization is deferred to a background goroutine, two rapid calls with indices (5, 3) would both be accepted and enqueued. The inner WAL catches the out-of-order index later, but that fires asynchronously — calling w.fail() which bricks the WAL. The caller of serializingWAL.Append(3, ...) already received a nil error and believes the append succeeded.

There's no appendMu equivalent to catch the misordering synchronously. Either mirror the appendMu gate here, or clearly document that the serializing wrapper provides strictly weaker ordering guarantees than the byte WAL.

}
req := serAppend{
index: index,
serialize: func() ([]byte, error) { return s.serialize(data) },

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The closure func() ([]byte, error) { return s.serialize(data) } captures data by value. For T = SomeStruct with slice fields, or T = []byte, the caller could mutate the underlying backing array between Append returning and the serializer goroutine running. The byte-oriented walImpl.Append is safer because it calls frameRecord(index, data) synchronously, copying the payload before enqueueing.

The doc already warns about this, but the asymmetry between the two WAL flavors (byte WAL copies eagerly, serializing WAL defers) is a sharp edge. Consider serializing eagerly on the caller's goroutine and enqueueing the already-serialized bytes, then it would match walImpl's behavior and also let you validate index ordering synchronously.

<-it.readerExited // wait for it to actually exit before releasing resources
it.wal.unpinIndex(it.pinnedIndex)
})
it.done = true

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Recommend to use atomic.Bool defensively — it costs nothing and prevents a real data race.
done is read by Next without any synchronization, so there could be data race if Close and Next overlap

WriteBufferSize: 16,
SerializerBufferSize: 16,
TargetFileSize: 64 * unit.MB,
FsyncOnFlush: true,

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Would recommend default to false though for performance concern

// NewWALWithRollback opens a byte-oriented WAL and deletes all records with an index greater than
// rollbackIndex before returning, so the WAL contains no record with an index greater than rollbackIndex.
func NewWALWithRollback(config *Config, rollbackIndex uint64) (WAL[[]byte], error) {
return newWAL(config, &rollbackIndex)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

an interrupted rollback is not resumed on a plain reopen, and the contract doesn't say so

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants